Skip to content

CI add visual regression tests - #672

Open
kroq-gar78 wants to merge 4 commits into
gallantlab:mainfrom
kroq-gar78:visual-regression-tests
Open

CI add visual regression tests#672
kroq-gar78 wants to merge 4 commits into
gallantlab:mainfrom
kroq-gar78:visual-regression-tests

Conversation

@kroq-gar78

@kroq-gar78 kroq-gar78 commented Aug 19, 2026

Copy link
Copy Markdown
Contributor

Rendering changes in Pycortex are currently only caught by eye. This PR automatically compares renders from both renderers (quickflat and webgl) against stored reference images, so a change in output fails a test instead of needing to be spotted in a screenshot. It also checks for consistency between the two renderers.

Tests

Four groups of reference images, covering the six public dataview classes through both renderers. (webgl references for Vertex2D are not included because it's broken on main, per gh-714.)

group num. images what it covers
alpha_dataviews/ 10 every way pycortex encodes alpha: Volume/Vertex as a no-alpha baseline, Volume2D via the 2D alpha colormap RdBu_r_alpha, the RGB classes via the alpha= keyword. All composite the curvature underlay.
nan_dataviews/ 10 the same six (Vertex2D xfailed) with NaNs over roughly half the primary data channel. Both renderers should draw those elements fully transparent, falling through to curvature, rather than mapping NaN through the colormap.
nan_alpha_dataviews/ 4 VolumeRGB/VertexRGB with the NaNs in the alpha map instead. A separate path: alpha is not colour-mapped but used directly as a blend weight, so the NaN reaches the compositing arithmetic.
nonflat_views/ 4 Volume and Vertex on the inflated and fiducial surfaces. webgl only — quickshow renders flatmaps and nothing else — so these get the reference check and no cross-renderer leg.

The images are tracked with git LFS and should be regenerated whenever a rendering change is blessed.

The within-renderer checks run at tighter tolerances than the cross-renderer checks, since the two renderers differ by a fixed affine transform. (fit_cross_renderer_affine.py re-derives the ideal transform.)

WebGL errors now fail loudly. Previously, WebGL console messages were not being caught properly due to buffering. This PR correctly propagates console error into pycortex. (This change would have caught gh-714.)

A blank render now fails loudly. save_3d_views also now raises on a blank view, naming the view and the file.

What it catches

pr_showcase

Three real bugs:

Notes for reviewers

  • On failure the tests write the actual render and an amplified difference into the test's tmp_path. There is no side-by-side composite; the two files are easier to flip between than one wide image.
  • git lfs pull if the reference images arrive as 130-byte text stubs. The tests skip rather than fail on that, and on the images being absent.
  • The two Vertex2D cases are xfail(strict=True). When the fix (Vertex2D objects do not render in webviewer #714) merges they turn XPASS.
  • matplotlib and playwright are now pinned in dev environments for consistency with the reference images.
  • CI fetches LFS only where the images are read. install_from_wheel.yml deliberately does not, since wheels exclude them.
  • cortex/tests/reference_images/README.md covers regeneration and provenance.

kroq-gar78 and others added 3 commits August 30, 2026 20:12
Renders the six dataview classes through quickshow and plot_panels -- for
alpha-bearing data, NaNs in the data, and NaNs in the alpha map -- plus
non-flatmap views through save_3d_views. Each render is checked against a
stored reference, and the two renderers against each other.

Four criteria per check, because they are complementary: the mean misses a few
pixels moving a long way, the gross fraction misses a broad low-amplitude
shift, and SSIM misses a channel permutation. The renderers do not share a
pixel grid, so webgl is mapped onto quickflat's frame by a fixed affine first
(fit_cross_renderer_affine.py re-derives it), and curvature is un-thresholded
on both sides -- which halves the cross-renderer floor at the cost of not
covering the default curvature path.

Vertex2D is xfailed strictly: its webgl flatmap fails to link its shader
(gallantlabgh-714), so it cannot be tested through that path. The test dependency group
pins playwright and matplotlib, which between them fix every renderer the
references depend on.
Twenty-eight lossless WebP (method=6, quality=100, exact=True) across
alpha_dataviews, nan_dataviews, nan_alpha_dataviews and nonflat_views, kept
separate from the code that reads them since no diff will explain them. Stored
as git LFS pointers from the outset, so the image data never enters this
repository's history.

Rendered by chromium 151.0.7922.34 and matplotlib 3.10.9, pinned in the test
dependency group. Regenerate with REGENERATE_REFERENCE_IMAGES=1 and review the
result: regenerating is how a real regression gets silently blessed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A shader that over-allocates vertex attributes compiles but fails to *link*,
and the viewer draws a blank canvas without any error (causes e.g. gallantlabgh-714).

Three defences. filter_webgl_failures() scans browser output for unambiguous
signatures. An event pump is what makes those messages arrive at all:
Playwright's sync API dispatches only while something calls into it, and the
worker parked in _shutdown_event.wait() for the whole session, so
save_3d_views' check was dead code reading a list that stayed empty until
teardown. _assert_not_blank inspects the pixels, for a driver that reports
nothing at all.

save_3d_views now raises RuntimeError naming the view and file, where a broken
Vertex2D previously surfaced as "zero-size array to reduction operation
minimum" from plot_panels' border trim.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@kroq-gar78
kroq-gar78 marked this pull request as ready for review August 31, 2026 03:30
@mvdoc

mvdoc commented Aug 31, 2026

Copy link
Copy Markdown
Contributor

@claude review

@claude

claude Bot commented Aug 31, 2026

Copy link
Copy Markdown

Claude finished @mvdoc's task in 3m 11s —— View job


Review of "CI add visual regression tests"

Todo list:

  • Gather context (PR body, changed files list)
  • Fetch branch / diff against origin/main (repo is a shallow clone; diffed against the PR's own base commit 3779f7c, which matches the stated +1526/-237)
  • Read CLAUDE.md for repo conventions (none present in this repo)
  • Review core new test infra (test_visual_regression.py, testing_utils.py)
  • Review headless.py / save_views.py changes (WebGL error propagation, blank-render detection)
  • Review CI workflow changes and .gitattributes/LFS setup
  • Review test_export.py / test_webgl_headless.py changes
  • Post review feedback

This is a very well-engineered PR — the calibration work behind the tolerances (test_visual_regression.py:114-233) and the affine-fit derivation (fit_cross_renderer_affine.py) is unusually rigorous, and the README covers regeneration/provenance clearly. A few small things worth a look:

1. Stale doc comment: handle is a JSMixer, not a WebApp
cortex/export/save_views.py:202:

pw_thread = handle._pw_thread # `handle` is a `WebApp`

Per headless_viewer's own docstring (cortex/export/headless.py:339-341), handle is the JSMixer; handle.server is the WebApp. Minor, but worth fixing so it doesn't mislead a future reader.

2. Typo: cortex/export/save_views.py:201 — "we don't capture console output in the interactivemode" → "interactive mode".

3. browser_errors accumulates for the lifetime of the session, never cleared per-view
In both save_views.py:205 (save_3d_views, one raise ends the loop, so this is harmless there) and test_webgl_headless.py's _assert_no_browser_failures, the check reads the entire history of console errors seen since the viewer was created. For TestAddData (test_webgl_headless.py:783-868), the handle is shared class-wide across several sub-tests that each call _assert_no_browser_failures. If an early sub-test in that sequence trips a spurious/transient WebGL warning, every later sub-test in the class will also fail on the same stale message, rather than the failure being isolated to where it happened. Not a false negative (nothing is missed), but it can produce a confusing cascade of failures pointing at the wrong test. Worth a # known limitation note, or clearing/snapshotting the error list at test boundaries, if this comes up in practice.

4. Minor style: cortex/tests/test_export.py:85-86 — only one blank line between test_plot_panels_headless and the new test_filter_webgl_failures_keeps_only_real_failures (PEP8 wants two between top-level defs). Cosmetic only.

Things that look solid:

  • filter_webgl_failures's narrow pattern list (cortex/export/headless.py:117-120) is well justified and has a dedicated unit test (test_export.py) that pins down both the false-positive noise (Leap Motion websocket, driver warnings) and the true positives (link failure, context-creation failure).
  • The four-criteria image comparison (MAX_MEAN_ABS_DIFF/MAX_FRACTION_DIFFERING/GROSS_DIFF_THRESHOLD/MAX_SSIM_LOSS) is justified with real worked examples (FIX NaN values in Volume/Vertex rendering as black instead of transparent #612, FIX unify NaN and alpha handling across quickflat, WebGL and RGB dataviews #695, NF: refactor webgl lighting #679) rather than arbitrary numbers — good practice for a visual-regression suite, which is otherwise prone to bit-rot tolerances nobody can justify later.
  • The LFS/wheel/sdist plumbing (.gitattributes, MANIFEST.in reliance, setup.py's exclude_package_data, the three workflow diffs) is internally consistent — I traced through each one and they agree with each other and with the README's "Distribution" section.
  • xfail(strict=True) on the two Vertex2D cases is the right call so the fix for Vertex2D objects do not render in webviewer #714 doesn't pass silently as a false negative.

No blocking issues found; the three code notes above (1-3) are polish, not correctness bugs.

- `handle` is the JSMixer, not the WebApp; `handle.server` is the WebApp.
- "interactivemode" -> "interactive mode".
- Two blank lines before the new test in test_export.py, per PEP8.
- Note that browser_errors is cumulative: with a shared handle, one transient
  failure fails every later test in the class too.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@kroq-gar78
kroq-gar78 force-pushed the visual-regression-tests branch from 8e84075 to dcd4c24 Compare August 31, 2026 03:55
@kroq-gar78

Copy link
Copy Markdown
Contributor Author

I pushed new commits that address problem 1, 2, and 4. I think it's not worth fixing problem 3, but I added a note for it.

@mvdoc mvdoc self-assigned this Aug 31, 2026

@mvdoc mvdoc left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

PR looks good. I ran the test locally and it worked, but it required a bit of fussing with dependencies. Claude gave the following comments. It may be worth adding some pointers in the docs in case we want to run things locally. (The CI tests seem to work fine.)

Two things worth flagging for the PR, both about git-lfs:

Without git-lfs installed, git checkout of this branch aborts outright — smudge filter lfs failed 127 — it doesn't degrade to pointer files, it refuses to check out. I got past it with filters disabled and then fetched the 28 objects through the LFS batch API. Contributors on a machine without git-lfs will hit a hard wall, so the README in reference_images/ should probably say git lfs install is a prerequisite. Related: this repo's LFS bandwidth is now on the hook for every clone that runs the tests.

Also, pytest.ini has testpaths=cortex with coverage on, so a bare uv run pytest on a dev machine with a real filestore in options.cfg will use that subject, not the bundled S1 stub — the reference images only match the stub. On your machine that surfaced as six PermissionErrors; on a writable filestore it would surface as confusing pixel diffs instead. Worth a note in the README, or a conftest that pins the filestore for these tests.

Comment on lines +24 to +26
# No lfs: true on purpose. This installs and imports a wheel, and
# exclude_package_data keeps the reference images out of wheels, so
# fetching them here would spend LFS bandwidth on files nothing reads.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'd remove this comment, or at least reword it. "No lfs: true on purpose" is confusing.

Comment thread cortex/export/headless.py
#: so VALIDATE_STATUS is false for want of a run, and getError() 0 is the
#: absence of an error. Driver shader-info warnings are excluded likewise.
WEBGL_FAILURE_PATTERNS = (
"THREE.WebGLProgram: Could not initialise shader",

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we do a final sweep and use American spelling instead of British?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

or is the error itself with british spelling? did we create that error type, or three.js?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The error is from three.js and has British spelling. Verbatim from the chromium console:

THREE.WebGLProgram: Could not initialise shader.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'll fix the spellings elsewhere. Maybe we should add something in CLAUDE.md to use American spelling conventions?

Comment thread cortex/export/headless.py

#: How often the worker thread calls into Playwright to dispatch queued browser
#: events; ``browser_errors`` is current to within this interval.
EVENT_PUMP_INTERVAL = 0.25

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

is "EVENT PUMP" standard terminology in this pattern or can we rename it to something more intuitive?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

fancy!

(`CROSS_MAX_MEAN_ABS_DIFF`, `CROSS_MAX_FRACTION_DIFFERING`), with no stored
fixture. `test_visual_comparison_nonflat_views` runs the reference check only.

## The cross-renderer affine correction

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this is fancy and if it works, fine. but wouldn't have it worked to just crop both images tightly, then match pixel size?

@kroq-gar78

kroq-gar78 commented Sep 2, 2026

Copy link
Copy Markdown
Contributor Author

Without git-lfs installed, git checkout of this branch aborts outright — smudge filter lfs failed 127 — it doesn't degrade to pointer files, it refuses to check out. [...] Contributors on a machine without git-lfs will hit a hard wall, so the README in reference_images/ should probably say git lfs install is a prerequisite.

I've only been able to reproduce the LFS checkout errors if 1) git-lfs & its hooks were installed in the repo, 2) the git-lfs binary was uninstalled without first removing the hooks (running git lfs uninstall), then 3) I checkout this branch. When I do that, I get this error:

$ git checkout visual-regression-tests
git-lfs filter-process: 1: git-lfs: not found
fatal: the remote end hung up unexpectedly

I don't have much experience with LFS, but this seems like a generic LFS issue (inconsistent configuration) and not something worth documenting in this repo. I was still able to check out this branch and run tests if git-lfs was never installed.

Also, pytest.ini has testpaths=cortex with coverage on, so a bare uv run pytest on a dev machine with a real filestore in options.cfg will use that subject, not the bundled S1 stub

I can force the tests (either ones just in this PR or all tests) to use the bundled pycortex db. If that's too strong, I can just write a warning at the beginning of test_visual_regression.py . (I'd prefer forcing it for all tests.)

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants